feat: Multi-chart UV packing (PackCharts)#99
Conversation
Add TestChartPacking.cpp covering edge cases, absolute/normalize scaling, non-overlap, extent, padding, and an end-to-end tear/extract/flatten/pack test with per-wedge recovery via the back-maps. ChartPacking.hpp provides PackOptions/PackResult and a stubbed PackCharts so the suite compiles and fails (red); implementation follows in Phase 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement multi-chart UV packing as a geometry-only free function that translates (and optionally uniformly scales) each chart's vertex positions in place. Charts are laid out with shelf packing using a sqrt-area target width (overridable); absolute scale is preserved by default with opt-in normalize to [0,1]^2 via a single global scale. Returns the packed atlas extent. Documents the vertex-identity per-wedge recipe and complexity. Wire ChartPacking.hpp into OpenABF.hpp and regenerate the single header. All 12 PackCharts tests pass (full suite 7/7). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The multiheader install enumerates public headers explicitly; ChartPacking.hpp was missing, so the installed OpenABF.hpp failed to find it (CI install-test, Multiheader=ON). Add it to the install list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The direct include of OpenABF/ChartPacking.hpp broke the single-header build (Multiheader=OFF), where only the amalgamated OpenABF.hpp exists. ChartPacking is included transitively via OpenABF.hpp, matching the other test files; drop the direct include. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update MultiChartFlatten to demonstrate PackCharts: flatten each connected component, pack the charts into a shared [0,1]^2 frame, merge them into one mesh, and write a single packed-atlas .obj instead of one file per chart. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add MergeMeshes<MeshType> -> MergedMesh{mesh, vertex_source, face_source}:
concatenates meshes into one and returns provenance maps (merged idx ->
{input mesh, source idx}) so the back-map chain survives a merge (compose
with each component's vertex_map/face_map to reach the torn source mesh).
Preserves vertex positions/traits; edge/face traits are default-constructed.
Switch the MultiChartFlatten example to use MergeMeshes instead of an inline
concatenate that discarded provenance. Wire the header into OpenABF.hpp and
the multiheader install set; regenerate the single header. New test suite
TestMeshMerge covers counts, provenance, throws, and an extract->merge
round-trip that recovers original face identity.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a non-compiled reference comment to MultiChartFlatten showing how to populate an educelab::core UVMap from the merged atlas: walk atlas faces back to the torn source mesh via face_source/face_map, take UVs from the packed chart vertices, and resolve corner positions by vertex identity (winding is not guaranteed stable). Demonstrates the optional WithChart trait for per-coordinate chart tagging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WithChart's chart index denotes independent packing domains (separate [0,1]^2 atlases / usemtl texture pages), not the connected components of a single shared atlas. This example packs all CCs into one atlas via a single PackCharts call, so the snippet now uses the default UVMap (one domain) and documents that WithChart applies only when running multiple independent packings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document that the per-wedge UVMap is valid for both the torn and the untorn (pre-split) mesh because corners are resolved by vertex identity against the target mesh's own faces, which absorbs any insert_face winding reversal at build/extract/merge. Note the caveat that as-built corner order may differ from the raw input face list (tracked separately). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…100) insert_face auto-reverses mis-wound faces (intended, makes almost-manifold input manifold) but silently changes a face's corner order vs the raw input and records no input->as-built permutation. Surfaced during F2; downstream per-corner round-trips can silently desync. Track B9 / issue #100 capture the problem and candidate fixes (reversal flag, permutation accessor, strict mode, or docs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… topology Rescope B9 beyond insert_face rewinding to the full problem: there is no recoverable path from a torn/parameterized/packed result back to the caller's original input topology. Two unrecorded identity shifts compose — insert_face corner-order reversal and split_edge seam-vertex duplication (original -> duplicate). Require B9 to provide invertible mappings for both, composing with F2's vertex_map/face_map and vertex_source/face_source so a consumer can name the original input face, corner position, and vertex for any atlas corner. Update issue #100 to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PackCharts applied `padding` only as a gutter between charts, so charts on the atlas perimeter still touched the boundary (left/bottom at the origin, rightmost/topmost at the extent). For a texture atlas that lets edge charts bleed across the boundary/seam under filtering, mipmapping, or wrap addressing. Inset the whole shelf layout: the cursor starts and wraps at `pad`, and `pad` is added to the far extents, so every chart has >= padding of empty space on all four sides, including against the atlas boundary. The atlas lower corner stays at the origin. normalize now fits the padded atlas into [0,1]^2. The library default stays padding = 0 (flush); the MultiChartFlatten example sets a visible padding to demonstrate the gutter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document that `padding` surrounds every chart on all four sides (incl. the atlas perimeter) in Design Decision 5 and the acceptance criteria, and add Phase 6 to the plan capturing the review question and resolution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
||
| template <typename U, std::size_t N> | ||
| struct VecDimensions<Vec<U, N>> { | ||
| static constexpr std::size_t value = N; |
There was a problem hiding this comment.
Why don't we just add Dimensions as a static Vec property properly?
There was a problem hiding this comment.
Done in 1013328 — added a static Vec::Dimensions member and dropped the detail::VecDimensions trait; the static_assert now reads VecType::Dimensions >= 2.
| * @tparam T Floating-point scalar type | ||
| */ | ||
| template <typename T> | ||
| struct PackResult { |
There was a problem hiding this comment.
This should just inherit the Vec type of the input HEMs, no?
There was a problem hiding this comment.
Done in 1013328 — PackResult is now templated on the mesh vertex position vector type (PackResult<VecType> with VecType min/max), deduced from MeshType::Vertex::pos, instead of a hardcoded Vec<T,2>. Note the extent now carries the full mesh vector type (e.g. Vec<T,3>), with only the u/v components meaningful — documented on the struct. Shout if you would rather it stay strictly 2D.
| auto mxX = std::numeric_limits<T>::lowest(); | ||
| auto mxY = std::numeric_limits<T>::lowest(); | ||
| for (const auto& v : chart->vertices()) { | ||
| mnX = std::min(mnX, v->pos[0]); |
There was a problem hiding this comment.
Why not std::minmax with a structured binding?
There was a problem hiding this comment.
Done in 1013328 — replaced the manual loop with two std::minmax_element calls (comparing pos[0]/pos[1]) and structured bindings.
| } | ||
| // Re-emit each face against the offset vertex indices, preserving the | ||
| // source face's corner order. | ||
| for (const auto& face : src->faces()) { |
There was a problem hiding this comment.
I guess insert_faces doesn't work here? You're not remembering to call mesh.update_boundary()
There was a problem hiding this comment.
Good catch — fixed in 1013328. MergeMeshes now gathers every face into one list and inserts them with a single out->insert_faces(faces), which rebuilds the boundary via update_boundary() once at the end.
| width[i] = mxX - mnX; | ||
| height[i] = mxY - mnY; | ||
| } | ||
|
|
There was a problem hiding this comment.
At least with this chart packing method, we need to add some sort of axis-aligned bounding box area minimization here (rotate in plane until AABB is minimized) before the we pack the charts. It should be a pack option, defaulting to being enabled.
There was a problem hiding this comment.
Added in 1013328 as the minimize_bounding_box pack option (default enabled). Each chart is rotated to its minimum-area orientation before packing: convex hull (monotone chain) + rotating-caliper search over hull-edge orientations. It runs in place and preserves topology/vertex identity, so back-maps stay valid.
Per your follow-up, it also stands each chart on its long axis (larger extent vertical) so orientation is deterministic and aligns with the tallest-first shelf strategy — narrower charts pack more per shelf, fewer shelves, less inter-shelf padding. Covered by new tests MinimizeBoundingBoxTightensRotatedChart, MinimizeBoundingBoxStandsWideChartUpright, and MinimizeBoundingBoxCanBeDisabled.
- Add static Vec::Dimensions; drop the detail::VecDimensions trait - Template PackResult on the mesh's vertex Vec type instead of Vec<T,2> - Use std::minmax_element with structured bindings for chart bounds - MergeMeshes: batch faces through insert_faces so update_boundary runs - PackCharts: add minimize_bounding_box option (default on) that rotates each chart to its minimum-area orientation and stands its long axis vertical to match the tallest-first shelf strategy Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Implements F2 — Multi-chart UV packing (closes #18): geometry-only free functions
PackCharts<MeshType>andMergeMeshes<MeshType>for laying out and merging already-parameterized charts into a shared coordinate frame.PackCharts translates (and optionally uniformly scales) each chart's 2D vertex positions in place using shelf packing, without touching topology or indices. Charts are sorted by height; layout wraps to a new shelf when the row width exceeds the target (defaults to
sqrt(total area)for a roughly square atlas).MergeMeshes concatenates a vector of charts into a single mesh, returning provenance maps (
vertex_source,face_source) so the atlas can be traced back through the component'svertex_map/face_mapto the original torn mesh.Design (resolved via design review)
pos; topology and indices unchanged, so anyExtractedComponentback-maps the caller holds stay valid.UVMaptype. The updatedMultiChartFlattenexample documents how to build a per-corner UV map from the packed atlas using back-maps, keyed by vertex identity (not corner position).normalizeapplies a single global uniform scale to fit[0,1]², preserving relative chart sizes and cross-chart texel density.paddingoption surrounds every chart on all four sides, including against the atlas boundary. Perimeter charts are inset from the packed extent bypadding, not merely separated from neighbors. Library default ispadding = 0; the example sets a visiblepadding = 0.1fto prevent texture filtering bleed.sqrt(Σ chart bbox area), overridable viaPackOptions.PackOptions{normalize, target_width, padding}andPackResult{min, max}(packed atlas extent);MergeMeshesreturnsMergedMesh{mesh, vertex_source, face_source}.std::invalid_argument;static_assert(Dim >= 2).Changes
Core Implementation:
include/OpenABF/ChartPacking.hpp—PackCharts,PackOptions,PackResultinclude/OpenABF/MeshMerge.hpp—MergeMeshes,MergedMeshinclude/OpenABF/OpenABF.hpp— includes for both new headerssingle_include/OpenABF/OpenABF.hpp— regenerated via amalgamationTests:
tests/src/TestChartPacking.cpp— 12 cases: bbox correctness, non-overlap with padding, absolute/normalize scaling, normalization fits[0,1]², extent containment, degenerate inputs, end-to-end tear→extract→flatten→pack with per-wedge recoverytests/src/TestMeshMerge.cpp— concatenation counts, vertex/face provenance, degenerate inputs, round-trip extract→merge identity recoveryExample:
examples/src/MultiChartFlatten.cpp— now tears a mesh, extracts and parameterizes components, packs them into a single atlas usingPackChartswith visiblepadding, merges back viaMergeMeshes, and writes a single packed atlas .obj. Includes extensive reference documentation for building a per-wedge UV map from the merged result and back-maps.Documentation:
Verification
ctest: 7/7 suites pass (incl. newOpenABF_TestChartPackingandOpenABF_TestMeshMerge).clang-formatclean.Complexity
O(n log n)in chart count +O(V)in total vertices;O(n)memory.🤖 Generated with Claude Code